Skip to content

Emit discardable-ODR variables via odr-use instead of UsedAttr in GetVariableOffset#1068

Open
conrade-ctc wants to merge 1 commit into
compiler-research:mainfrom
conrade-ctc:odruse-instead-of-usedattr-in-getvariableoffset
Open

Emit discardable-ODR variables via odr-use instead of UsedAttr in GetVariableOffset#1068
conrade-ctc wants to merge 1 commit into
compiler-research:mainfrom
conrade-ctc:odruse-instead-of-usedattr-in-getvariableoffset

Conversation

@conrade-ctc

@conrade-ctc conrade-ctc commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

ForceCodeGen forces deferred emission by planting a permanent __attribute__((used)) on the decl. Every global emitted that way gets a WeakTrackingVH in codegen's llvm.used list; when such a global is deleted before emitUsed runs (e.g. ORC freeing a materialized PTU's IR), the handle nulls and release-built clang dereferences it unchecked — SIGSEGV in ConstantExpr::getPointerBitCastOrAddrSpaceCast under CodeGenModule::Release() of a later incremental parse. The failure is cumulative interpreter state, not a culprit type: it only manifests after enough force-emitted statics plus JIT materialization cycles (we hit it on large reflection sweeps; per-type bisection never converged), so the regression test asserts the mechanism instead — no UsedAttr planted, no llvm.used residue in later PTU modules — plus a sweep-shaped smoke loop.

GetVariableOffset now emits GVA_DiscardableODR variables (the inline/constexpr class the crash traced to) by declaring an external-linkage odr-use of the qualified name: the definition flows through the regular deferred-decl path and leaves no used-list residue and no permanent attribute. Everything else deliberately stays on the UsedAttr path:

  • Internal-linkage variables cannot be odr-used from a later PTU (the module-local symbol duplicates or goes missing) — getting this wrong broke VariableReflection_GetVariableOffset (static int S) and cppyy's Lifeline::count lookup.
  • Available-externally definitions would not be emitted by a mere reference.
  • Template-specialization and anonymous/lambda spellings do not reliably round-trip as source (a printed LLONG_MIN non-type argument re-parses as an overflowing literal), and a parse-failing declaration poisons the incremental interpreter.

Also evaluated and rejected: Interpreter::Undo(1) on parse failure to drop failed PTUs — cppyy depends on failed parses leaving side-effect declarations (test_templates test32's explicit-instantiation probe).

Concurrency: the dummy-name counter is deliberately a plain static unsigned, matching the file's existing gWrapperSerial idiom. The interpreter API is caller-serialized (the incremental parse either route drives is not thread-safe, and the file's only sync primitive is the process-init once_flag); an atomic here would advertise a guarantee the surrounding function doesn't have. Worst case if raced anyway: a duplicate dummy name fails the Declare and the caller falls back to ForceCodeGen.

Known residual, unchanged here: ForceCodeGen's function path (GetFunctionAddress) still uses UsedAttr — same theoretical hazard, never observed.

Independent of #1067 (both touch GetVariableOffset; whichever lands second has a trivial test-file conflict).

🤖 Done with the help of Claude Code (Fable 5, human in the loop)

@conrade-ctc

Copy link
Copy Markdown
Contributor Author

cc @vgvassilev @rathorey-ctc

@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.11%. Comparing base (321657d) to head (d3bd5d3).

Files with missing lines Patch % Lines
lib/CppInterOp/CppInterOp.cpp 91.42% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1068      +/-   ##
==========================================
+ Coverage   87.07%   87.11%   +0.04%     
==========================================
  Files          23       23              
  Lines        6073     6109      +36     
==========================================
+ Hits         5288     5322      +34     
- Misses        785      787       +2     
Files with missing lines Coverage Δ
lib/CppInterOp/InterpreterInfo.h 100.00% <100.00%> (ø)
lib/CppInterOp/CppInterOp.cpp 89.66% <91.42%> (+0.04%) ⬆️
Files with missing lines Coverage Δ
lib/CppInterOp/InterpreterInfo.h 100.00% <100.00%> (ø)
lib/CppInterOp/CppInterOp.cpp 89.66% <91.42%> (+0.04%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clang-tidy made some suggestions

There were too many comments to post at once. Showing the first 10 out of 11. Check the log or trigger a new build to see more.

Comment thread lib/CppInterOp/CppInterOp.cpp Outdated
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp Outdated
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp Outdated
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp Outdated
Comment thread unittests/CppInterOp/VariableReflectionTest.cpp Outdated
@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from 916eeb4 to 79d2fdb Compare July 15, 2026 19:51
@vgvassilev

Copy link
Copy Markdown
Contributor

This entire approach of adding these attributes to force emission is sketchy to say the least. Can you ask Claude what alternatives we have including perhaps minimal reasonable modifications to clang.

@conrade-ctc

Copy link
Copy Markdown
Contributor Author

This entire approach of adding these attributes to force emission is sketchy to say the least. Can you ask Claude what alternatives we have including perhaps minimal reasonable modifications to clang.

ya, i didn't love this at all either... felt pretty gross to me. let me iterate a bit and see what we might do upstream to address it.

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@vgvassilev

Copy link
Copy Markdown
Contributor

This entire approach of adding these attributes to force emission is sketchy to say the least. Can you ask Claude what alternatives we have including perhaps minimal reasonable modifications to clang.

ya, i didn't love this at all either... felt pretty gross to me. let me iterate a bit and see what we might do upstream to address it.

I did not mean your patch but the overall logic in this area.

@conrade-ctc

Copy link
Copy Markdown
Contributor Author

CI triage for the three red jobs — none traces to this diff:

The six cling compile failures from the first push are fixed (PTU white-box assertions now gated to clang-repl builds).

@conrade-ctc

Copy link
Copy Markdown
Contributor Author

Asked Claude to survey the design space; here's the grounded version (file/line refs against release/21.x).

Why the current approach is sketchy (agreed): ForceCodeGen mutates the AST as a side effect of a reflection query — UsedAttr::CreateImplicit is visible to every later semantic check — and the attr drags the emitted global into llvm.used, a std::vector<llvm::WeakTrackingVH>. Incremental PTUs can delete such a global, the handle goes null, and emitUsed does cast<llvm::Constant>(&*List[i]) with no null check (CodeGenModule.cpp:~3203) — that's the crash this PR routes around. On top, the generated module's internal-linkage globals get rewritten to weak.

What CppInterOp can do without touching clang: essentially this PR — a source-level odr-use where the qualified name round-trips (falling back to UsedAttr for template-specialization spellings and anonymous scopes). The semantically honest AST-level version — Sema::MarkVariableReferenced + HandleTopLevelDecl — dead-ends because ASTContext::DeclMustBeEmitted doesn't consult isUsed() for variables (the FIXME in ForceCodeGen says exactly this), so codegen still defers the definition.

Minimal clang modification #1 (tiny, worth doing regardless): null-skip deleted globals in emitUsed's loop. In incremental mode a used global can legitimately be destroyed between PTUs; today that's a release-build null deref. ~3 lines, protects every existing embedder of the UsedAttr pattern — including this PR's own fallback branch — and needs no design discussion. I'll send that patch independently.

Minimal clang modification #2 (the principled end-state): an emission API on clang::Interpreter — e.g. emitGlobalDecl(GlobalDecl). The pieces already exist: CodeGenerator::GetAddrOfGlobal(GD, /*isForDefinition=*/true) is public (ModuleBuilder.h:98) and schedules the deferred definition (GetOrCreateLLVMGlobal promotes the DeferredDecls entry, which moveLazyEmissionStates explicitly carries across incremental modules — CodeGenModule.cpp:8111); all that's missing is that Interpreter::getCodeGen() is private, so embedders can't reach it. Such an API would emit with natural linkonce_odr linkage — no attribute, no llvm.used residue, no name round-trip, works for the template/anonymous shapes this PR has to punt on — and would let ForceCodeGen's whole UsedAttr + Parse("") + linkage-rewrite dance be deleted.

Suggested sequencing: land this PR now as the crash fix — it helps every clang version that exists today, and CppInterOp's supported-version window means the eventual cleanup is a version gate rather than a revert (prefer emitGlobalDecl under #if CLANG_VERSION_MAJOR >= N once the API lands, keep this mechanism as the legacy branch for older majors). The emitUsed null-skip goes upstream immediately on its own; emitGlobalDecl goes through design review on its own cycle. Happy to draft both clang patches.

@vgvassilev

Copy link
Copy Markdown
Contributor

Sounds like a good plan actually.

@conrade-ctc

conrade-ctc commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main to clear the conflict with #1067 — its definition-first structure is preserved, with the odr-use dispatch layered on top of the discardable-linkage branch; both PRs' tests kept. Gates green locally (check-cppinterop, both GetVariableOffset tests pass in InProcess and OutOfProcess JIT modes; clang-format clean).

Step 2 of the plan is also up: llvm/llvm-project#210959 null-skips deleted globals in emitUsed. While writing it I found a pure clang-repl reproducer — no CppInterOp needed: GetOrCreateLLVMGlobal erases a same-mangled-name/different-type global without RAUW when it has no IR uses, and value handles aren't uses, so the used-list entry nulls instead of retargeting:

clang-repl> __attribute__((used)) int a asm("sym") = 1; float b asm("sym") = 2.0f;
clang-repl> int ok = 0;   // Assertion `V && "Dereferencing deleted ValueHandle"' failed

@vgvassilev — ready for another look.

CI note: the three emscripten job failures are infra — micromamba env setup got an HTTP 500 from prefix.dev fetching repodata, before anything was compiled. They should clear on a rerun (I lack rerun rights here). The root job is the known pre-existing configure breakage.

Comment thread lib/CppInterOp/CppInterOp.cpp Outdated
llvm::raw_string_ostream OS(name);
VD->printQualifiedName(OS);
}
if (name.find("(anonymous ") != std::string::npos ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not do string comparisons but we should use the relevant api to get the same information from the ast/sema.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5f96515 — the string sniffing is gone. The guard now asks the AST directly: bail out for declarations without an IdentifierInfo or that are VarTemplateSpecializationDecls, then walk the DeclContext chain rejecting anonymous namespaces, unnamed/lambda records, ClassTemplateSpecializationDecl scopes, and anything else that isn't a named namespace/record (linkage-spec/export blocks are skipped as transparent). printQualifiedName is now only used to spell the odr-use for names the walk has proven spellable. Added test coverage for the anonymous-namespace bailout and the linkage-spec passthrough.

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from 8aac73a to 5f96515 Compare July 21, 2026 13:22
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from 5f96515 to 0b6e999 Compare July 21, 2026 13:59
@conrade-ctc

conrade-ctc commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

CI triage on the last run, all addressed or external:

Comment thread lib/CppInterOp/CppInterOp.cpp Outdated
static unsigned Counter = 0;
std::string code = "namespace __cppinterop_odr_use { const void* __v" +
std::to_string(Counter++) +
" = (const void*)__builtin_addressof(::" + name + "); }";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid that string manipulation, too? This leads to memory hoarding on long sessions... Perhaps we should just do what __builtin_addressof would do by calling it directly in Sema if possible...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in e11ca91 — the source-text mechanism is gone entirely. The odr-use anchor is now built directly in Sema: BuildDeclRefExpr + CreateBuiltinUnaryOp(UO_AddrOf) (the AST parsing &var would produce), stored into a synthesized external-linkage const void* dummy at TU scope whose initializer conversion Sema checks, then handed to codegen the way ForceCodeGen already does (HandleTopLevelDecl + empty-parse flush). No per-query source buffers or string assembly beyond the dummy's identifier.

Two knock-on wins:

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"


// Build '&VD' the way the parser would; the address-of marks Def
// odr-used, which is what schedules the deferred definition.
ExprResult Ref = S.BuildDeclRefExpr(Def, Def->getType().getNonReferenceType(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still hoards much less memory though. Can we add a fixme note with two lines annotating that we want to probably move that under a scratch area where these ast nodes get deallocated and add a ifdef llvm_version < 24 to remind ourselves to remove it once the other fix lands hopefully upstream?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d317206 — two FIXMEs added: one for moving the synthesized nodes under a deallocatable scratch area, one for removing the whole anchor mechanism once clang grows a direct emission API, with a #if CLANG_VERSION_MAJOR >= 24 + #warning tripwire so the clang-24 bump forces the revisit.

return true;
#else // CLANG_REPL
I.getCI()->getASTConsumer().HandleTopLevelDecl(DeclGroupRef(Dummy));
auto GeneratedPTU = I.Parse("");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s worth another fixme pointing out that bug which forces parsing an empty string to reset the state in the compiler. I thought we had some raii to use to annotate these places.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RAII you're thinking of is compat::SynthesizingCodeRAII — under cling it's PushTransactionRAII (commit = emission), but its clang-repl variant is a stub with a TODO destructor (Compatibility.h). d317206 now uses it on both paths as the annotation, and the empty-parse flush carries a FIXME naming it as the thing the RAII's clang-repl destructor should eventually own.

@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from e11ca91 to d317206 Compare July 23, 2026 13:23
Comment on lines +455 to +457
std::string probe = "template <> struct Sweep";
probe += n;
probe += "<int>;";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any value in accumulating in this way? Why can't we do R"(...)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, this seems to be a claude default, and I didn't catch it, shouldn't be building the string this way. I'll update this and push, and refine CLAUDE.md!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No value in it — that shape was appeasing clang-tidy's inefficient-concatenation check, at readability's expense. Reworked in the current head: the sweep bodies are raw strings (R"(struct Sweep@ { inline static int v@ = @; };)") with a tiny @→index substitution, no accumulation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still don't get it -- can't we have a normal preprocessor macro with a stringify operation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 60c19fe — the sweep sources are now assembled entirely at compile time: a SWEEP_CASE(n) macro stringifies the index (#n) into adjacent string literals, expanded into a constexpr std::array for 0–7. No runtime string building left in the test.

Same push also fixes what the osx cling lanes caught on the previous head: under cling, the odr-use marking in BuildDeclRefExpr can trigger an immediate static-member instantiation, and DeclCollector aborts if no transaction is open — the SynthesizingCodeRAII now opens before any Sema work in EmitVariableViaOdrUse (same pattern as the InstantiateVariableDefinition call site from #1067).

@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from d317206 to ec96b54 Compare July 23, 2026 13:50
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from ec96b54 to 3864244 Compare July 23, 2026 14:25
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from 3864244 to 60c19fe Compare July 24, 2026 10:59
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

Comment thread lib/CppInterOp/CppInterOp.cpp Outdated
// FIXME: The synthesized nodes are parked in the TU for the rest of the
// session; move them under a scratch area whose AST nodes get deallocated
// after emission.
static unsigned Counter = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That will hurt the multiple interpreters setup. We need to move that variable in the InterpreterInfo object.

@conrade-ctc conrade-ctc Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, we must not have a test that exposes that. I'll try to add one, and fix. Thanks for all the iteration on this PR!

@conrade-ctc conrade-ctc Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d3bd5d3OdrUseCounter now lives in InterpreterInfo (accessed via getInterpInfo(&I), and carried through the move ops since DeleteInterpreter's mid-deque erase relocates later entries). Added a regression test first, which fails with the static: VariableReflection_GetVariableOffset_OdrUseAnchorPerInterpreter pins that a fresh interpreter's first anchor is always its own v0, independent of sibling interpreters' (or the process's) query history.

Same push covers the anchor's error paths for the codecov patch gate: the Parse/Execute failure arms now share one consume-and-fail block, exercised by a new UnmaterializableDefinition test (a definition whose dynamic initializer needs an undefined symbol — parses fine, materialization fails, the query fails cleanly).

(Amended 58b9a9097716d3: the new failure-path test provokes an intentional JIT error whose "JIT session error :" stderr line MSBuild promotes to a build error on the win-msvc lane — the test now skips on _WIN32; coverage comes from the linux lanes.)

(Amended 97716d3d3bd5d3: the same test now also skips on __EMSCRIPTEN__ — its intentional failed load poisons wasm dynamic linking, #1071 — and the Windows skip message no longer itself contains the error : token MSBuild scrapes.)

@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from 60c19fe to 58b9a90 Compare July 24, 2026 11:56
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from 58b9a90 to 97716d3 Compare July 24, 2026 12:16
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

…bleOffset

ForceCodeGen forces deferred emission by planting a permanent
__attribute__((used)) on the decl; every global emitted that way gets a
WeakTrackingVH in codegen's llvm.used list. When such a global is
deleted before emitUsed runs (ORC freeing a materialized PTU's IR), the
handle nulls and release-built clang dereferences it unchecked — a
process crash that accumulates with interpreter state rather than
tracing to any one declaration (large reflection sweeps died; per-type
bisection never converged, so the crash itself has no compact unit
repro — root-caused via gdb).

GetVariableOffset now emits GVA_DiscardableODR variables (the
inline/constexpr class the crash traced to) by declaring an
external-linkage odr-use of the qualified name instead: the definition
flows through the regular deferred-decl path and leaves no used-list
residue, no permanent attribute. Everything else keeps the UsedAttr
path, deliberately:

- Internal-linkage variables cannot be odr-used from a later PTU (the
  module-local symbol duplicates or goes missing); getting this wrong
  broke VariableReflection_GetVariableOffset (static int S) and cppyy's
  Lifeline::count lookup.
- Available-externally definitions would not be emitted by a mere
  reference.
- Template-specialization and anonymous/lambda spellings do not
  reliably round-trip as source (a printed LLONG_MIN non-type argument
  re-parses as an overflowing literal), and a parse-failing declaration
  poisons the incremental interpreter.

An interpreter-level alternative — Undo(1) on parse failure to drop the
failed PTU — was evaluated and rejected: cppyy depends on failed parses
leaving side-effect declarations (an explicit-instantiation probe of a
declared-but-undefined template is expected to fail and leave the
specialization decl behind; test_templates test32).

The dummy-name counter is deliberately plain, matching gWrapperSerial:
the interpreter API is caller-serialized. ForceCodeGen's function path
(GetFunctionAddress) still uses UsedAttr — same theoretical hazard,
never observed.

The test pins the mechanism: after an offset query on a
discardable-ODR static, the decl carries no UsedAttr and later PTU
modules carry no llvm.used, with a sweep-shaped regression net over
interleaved absorbed parse failures.

Co-developed-with-the-help-of: Claude Code (Fable 5, human in the loop)
@conrade-ctc
conrade-ctc force-pushed the odruse-instead-of-usedattr-in-getvariableoffset branch from 97716d3 to d3bd5d3 Compare July 24, 2026 13:25
@github-actions

Copy link
Copy Markdown
Contributor

clang-tidy review says "All clean, LGTM! 👍"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants